Updated: jQuery Animating Same-Page #Links Bugs

published:
2009.01.18
topics:
javascript

Hey, read this first: There has been I'm sure many further enhancements since my patches. I'd check up on the smooth scroll github project hosted by Karl Swedberg. He is the original author of the code that I patched and has been maintaining everything.

Update: I have developed a potential fix for the Opera bug Karl Swedberg discussed in his comment on this post. Update #2: I've made further changes after Karl's second comment.

When designing this site I decided I wanted to animate smoothly scrolling to same-page #links, such as my link to the moon. I decided to use the "improved" version of the animated scrolling code originally written by Karl Swedberg. I've since found two fixed three bugs with this code. Here is the original code:

function enable_smooth_scroll() {
    function filterPath(string) {
        return string
                .replace(/^\//,'')
                .replace(/(index|default).[a-zA-Z]{3,4}$/,'')
                .replace(/\/$/,'');
    }

    var locationPath = filterPath(location.pathname);
    $('a[href*=#]').each(function() {
        var thisPath = filterPath(this.pathname) || locationPath;
        if  (   locationPath == thisPath
                && (location.hostname == this.hostname || !this.hostname)
                && this.hash.replace(/#/, '')
            ) {
                var $target = $(this.hash), target = this.hash;
                if (target) {
                    var targetOffset = $target.offset().top;
                    $(this).click(function(event) {
                        event.preventDefault();
                        $('html, body').animate(
                            {scrollTop: targetOffset},
                            500,
                            function() {
                                location.hash = target;
                        });
                    });
                }
        }
    });
}

First Bug: Non-existent #Links Kill JS Execution

The first bug I found was when I had an anchor tag in my page with a #link that didn't actually exist. Karl's code choked and killed my Javascript execution. I shouldn't really be linking to places that don't exist, but nonetheless I thought this needed fixing.

Turns out my colleague Paul Armstrong had encountered the same bug and had a fix. The problem code was $target.offset().top which was being called when $target was empty. Paul's smart fix was to change the condition if (target) to if ($target.length):

…
var $target = $(this.hash), target = this.hash;
if ($target.length) { // changed this line
    var targetOffset = $target.offset().top;
…

The variable $target is set to the return value from $(this.hash), which will be an array of jQuery objects. If the #link stored in this.hash doesn't exist then $(this.hash) would return an empty array, otherwise it would return an array with one item. This is why we check the length property of $target.

Second Bug: Pre-calculated Scroll Offsets Fail with Dynamic Content

The second bug I found was while using the jQuery SWFObject Plugin (great theme song!). The Flash movies I was embedding, in this case a Flash video player, did not affect the page height until after the plugin executed and embedded them into the page. This execution and embedding happened with the jQuery ready() event, as did the call the enable_smooth_scroll().

The problem was that the smooth scroll code pre-calculated all of the #link scroll offsets and stored them in the click() callback function for each. If enable_smooth_scroll() executed before the Flash movies were embedded in the DOM, or if anything else changed the page height after the call to the smooth scrolling function, then the #links would not work correctly.

The best fix to me was to not pre-calculate these scroll offsets, but instead find them at the moment the #link was actually clicked. This would ensure that the page would smoothly scroll to the correct location. In order to do this I had to move some variables from the scope of the enable_smooth_scroll() function into the scope of each click() callback for the #links:

…
//delete var $target = $(this.hash), target = this.hash;
//delete if ($target.length) {
if ($(this.hash).length) {
    //delete var targetOffset = $target.offset().top;
    $(this).click(function(event) {
        var targetOffset = $(this.hash).offset().top;
        var target = this.hash;
        event.preventDefault();
…

(Update) Third Bug: Opera Does Not Animate Correctly

Karl Swedberg commented below about an Opera bug where the scrolling animation would not work correctly when body was included in the jQuery animate selector. It could be fixed by changing $('html, body').animate to $('html').animate, however this would break other browsers which depend on using body. Karl asked for a solution that did not involve browser User Agent sniffing to avoid maintaining a User Agent if condition in the code and also because Opera users may spoof their User Agent string. I found such a solution.

I spent a little time interrogating the jQuery objects returned by $('html') and $('body'). I was hoping there was a way to programmatically determine for any past, present, or future browser which of these two scrollTop properties to animate. At first I came to the unfortunate conclusion that for any given browser the values of these two objects do not tell us anything helpful. There isn't some sort of magic "animated this one" value that stands out.

Then I tried something different. I ran the code $('html, body').attr('scrollTop', 1); and noticed that in each browser the scrollTop value was changed for one element and remained zero for the other! It turns out that for IE6, IE7, FF2, FF3, Safari, and Opera you can detect whether to animate $('html') or $('body') by trying to change the scrollTop property of both. Your change will only affect the scrollTop property for the element that should be animated. If you immediately set scrollTop back to zero there is no apparent visual tick (that I ever noticed).

Here is what the test looks like:

var scrollElement = 'html, body';
$('html, body').attr('scrollTop', 1);
if ($('html').attr('scrollTop') == 1) {
    scrollElement = 'html';
} else if ($('body').attr('scrollTop') == 1) {
    scrollElement = 'body';
}
$('html, body').attr('scrollTop', 0);

We can then pass the variable scrollElement along to our animation function call:

…
event.preventDefault();
$(scrollElement).animate(
    {scrollTop: targetOffset},
…

But then Firefox throws a wrench in the gears. If you follow a #link onto a different page on your site that also loads this smooth scrolling code, Firefox will change the value of $('html').attr('scrollTop') before the execution of any JavaScript. If we change scrollTop to 1 and then back to 0 in Firefox, the scrollbar will reset to the top of the document, and we will effectively break different-page #links.

Update #2: With the help of Karl's suggestion in his comment below, I came up with a clean block of code that will detect which element to animate while not interfering with situations where scrollTop is non-zero on page load:

var scrollElement = 'html, body';
$('html, body').each(function () {
    var initScrollTop = $(this).attr('scrollTop');
    $(this).attr('scrollTop', initScrollTop + 1);
    if ($(this).attr('scrollTop') == initScrollTop + 1) {
        scrollElement = this.nodeName.toLowerCase();
        $(this).attr('scrollTop', initScrollTop);
        return false;
    }    
});

This method is actually quite effective, and as Karl pointed out, it is immune to User Agent spoofing. Also, jQuery browser sniffing has been deprecated in version 1.3.

Final Code

For your convenience, here is the final code in its entirety including all bug fixes:

function enable_smooth_scroll() {
    function filterPath(string) {
        return string
                .replace(/^\//,'')
                .replace(/(index|default).[a-zA-Z]{3,4}$/,'')
                .replace(/\/$/,'');
    }

    var locationPath = filterPath(location.pathname);
    
    var scrollElement = 'html, body';
    $('html, body').each(function () {
        var initScrollTop = $(this).attr('scrollTop');
        $(this).attr('scrollTop', initScrollTop + 1);
        if ($(this).attr('scrollTop') == initScrollTop + 1) {
            scrollElement = this.nodeName.toLowerCase();
            $(this).attr('scrollTop', initScrollTop);
            return false;
        }    
    });
    
    $('a[href*=#]').each(function() {
        var thisPath = filterPath(this.pathname) || locationPath;
        if  (   locationPath == thisPath
                && (location.hostname == this.hostname || !this.hostname)
                && this.hash.replace(/#/, '')
            ) {
                if ($(this.hash).length) {
                    $(this).click(function(event) {
                        var targetOffset = $(this.hash).offset().top;
                        var target = this.hash;
                        event.preventDefault();
                        $(scrollElement).animate(
                            {scrollTop: targetOffset},
                            500,
                            function() {
                                location.hash = target;
                        });
                    });
                }
        }
    });
}